往往排序是作为其他算法的预处理算法,其重要性却不容小觑。本篇讲冒泡排序/选择排序/插入排序/希尔排序/归并排序/快速排序/桶排序/计数排序。
概览
algorithm | in-place | worst | average | best | space complexity | remark |
---|---|---|---|---|---|---|
bubble | yes | $N^2$ | $N^2$ | N | $O(1)$ | —— |
selection | yes | $N^2$ | $N^2$ | $N^2$ | $O(1)$ | —— |
insertion | yes | $N^2$ | $N^2$ | N | $O(1)$ | —— |
shell | yes | —– | ——- | N | $O(1)$ | —— |
merge | $NlogN$ | $NlogN$ | $NlogN$ | $O(N)$ | —— | |
quick | yes | $N^2$ | $NlogN$ | $NlogN$ | $O(logN)$ | —— |
heap | yes | $NlogN$ | $NlogN$ | $NlogN$ | $O(1)$ | —— |
Bubble sort 冒泡排序
冒泡排序的原理非常简单,重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。
内部稳定排序,时间复杂度 $O(N^2)$,空间复杂度 $O(1)$
步骤:
- 比较相邻的元素。如果前一个比后一个大,就交换他们两个。
- 对第 0 个到第 n-1 个数据做同样的工作。这时,最大的数就“浮”到了数组最后的位置上。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
每轮操作都将一个最大的数“浮”到最后的位置,也就是每轮都有最后 N-i 个数已经排序,所以内循环是从 1 到 N-i。
基本款
|
|
优化一
优化1:某一趟遍历如果没有数据交换,则说明已经排好序了,因此不用再进行迭代了。用一个标记记录这个状态即可。
优化二
优化2:记录某次遍历时最后发生数据交换的位置,这个位置之后的数据显然已经有序,不用再排序了。因此通过记录最后发生数据交换的位置就可以确定下次循环的范围了。
Selection sort 选择排序
选择排序无疑是最简单直观的排序。
内部不稳定排序,时间复杂度 $O(N^2)$,空间复杂度 $O(1)$
步骤:
- 在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
以此类推,直到所有元素均排序完毕。
|
|
Insertion sort 插入排序
每轮在已经排好的序列中插入一个新的数字。插入排序的工作原理是,对于每个未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
内部稳定排序,时间复杂度 $O(N^2)$,空间复杂度 $O(1)$
步骤:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果被扫描的元素(已排序)大于新元素,将该元素后移一位
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后
- 重复步骤2~5
|
|
Shell Sort 希尔排序
希尔排序,也称递减增量排序算法,实质是分组插入排序。
内部非稳定排序算法。时间复杂度不定,空间复杂度$O(1)$
希尔排序的基本思想是:将数组列在一个表中并对列分别进行插入排序,重复这过程,不过每次用更长的列(步长更长了,列数更少了)来进行。最后整个表就只有一列了。将数组转换至表是为了更好地理解这算法,算法本身还是使用数组进行排序。
例如,假设有这样一组数[ 13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10 ],如果我们以步长为5开始进行排序,我们可以通过将这列表放在有5列的表中来更好地描述算法,这样他们就应该看起来是这样:
13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10
然后我们对每列进行排序:
10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45
将上述四行数字,依序接在一起时我们得到:[ 10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45 ]。这时10已经移至正确位置了,然后再以3为步长进行排序:
10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45
排序之后变为:
10 14 13 25 23 33 27 25 59 39 65 73 45 94 82 94
最后以1步长进行排序(此时就是简单的插入排序了)。
|
|
上面源码的步长的选择是从n/2开始,每次再减半,直至为0。步长的选择直接决定了希尔排序的复杂度
Merge Sort 归并排序
归并排序是采用分治法的一个非常典型的应用。归并排序的思想就是先递归分解数组,解决子集的排序问题,再合并两个有序数组。由于基本的逻辑思维结构是二叉树,也叫二路归并排序。
先考虑合并两个有序数组,基本思路是比较两个数组的最前面的数,谁小就先取谁,取了后相应的指针就往后移一位。然后再比较,直至一个数组为空,最后把另一个数组的剩余部分复制过来即可。
再考虑递归分解,基本思路是将数组分解成 left 和 right,如果这两个数组内部数据是有序的,那么就可以用上面合并数组的方法将这两个数组合并排序。如何让这两个数组内部是有序的?可以再二分,直至分解出的小组只含有一个元素时为止,此时认为该小组内部已有序。然后合并排序相邻二个小组即可。
外部稳定排序。时间复杂度 $O(nlog(n))$,空间复杂度 $O(n)$。堆排序和快速排序的复杂度也都是 $O(nlog(n))$,但它们是不稳定的。在排序算法中,时间复杂度是 $O(nlog(n))$ 的排序算法只有归并排序。
基本款
|
|
优化
结合其他排序
在数组长度比较短的情况下,不进行递归,而是采用其他排序方案,如 high - low < 50 时,可以用快速/插入排序,适合整体时间最优
建立索引
实际过程中,可能排序的不是 int, 而是一个结构体,在此情况下,可以通过记录数组下标(index)来代替申请新内存空间,从而避免 A 和辅助数组间的频繁数据移动。
应用
归并排序非常适合做外排序(external sorting)。
例1: 9 路归并排序
用 100M 内存对 900M 数据进行排序
- 读入 100M 数据至内存,用常规方式(堆排序)排序
- 将排序后的数据写入磁盘
- 重复前两个步骤,得到 9 个 100M 的文件块。
- 将 100M 内存划分为 10 块,前 9 份为输入缓冲区,最后一个为输出缓冲区。如将 9 个 100M 的文件块每个分前 10M 放到输入缓冲区,然后同时指向第一个元素,把最小的那个放到输出缓冲区,然后指针后移一位。
- 执行 9 路归并算法,将结果输出到输出缓冲区。
- 输出缓冲区满,写入目标文件,清空缓冲区
- 输入缓冲区空,读入相应文件的下一份数据。
例2: 逆序数问题
给定一个数组 A[0..N-1],如果对于两个元素 a[i],a[j],有 i
当然可以选择暴力求解,两个循环,对每个数都要扫描它前面的所有数,时间复杂度为 $O(N^2)$
i -> [0,N-1]
j -> [i+1,N-1]
这里也可以用归并排序的思想。比如观察归并排序——合并数列(1,3,5)与(2,4):
- 先取出前面数列中的1。
- 然后取出后面数列中的2,明显这个2和前面的3,5都可以组成逆序数对即3和2,5和2都是逆序数对。
- 然后取出前面数列中的3。
- 然后取出后面数列中的4,同理,可知这个4和前面数列中的5可以组成一个逆序数对。
|
|
其他思考
思考:原地排序?让空间复杂度为 $O(1)$
Quick sort 快速排序
快速排序通常明显比同为Ο(nlogn)的其他算法更快,因此常被采用,而且快排采用了分治法的思想,所以在很多笔试面试中能经常看到快排的影子。可见掌握快排的重要性。
内部不稳定排序,最差时间复杂度 $O(N^2)$,平均时间复杂度 $O(nlogn)$
步骤:
- 从数列中挑出一个元素作为基准数。
- 分区过程,将比基准数大的放到右边,小于或等于它的数都放到左边。
- 再对左右区间递归执行第二步,直至各区间只有一个数。
基本款
|
|
上面的代码选择第一个数作为基准数。
应用
正整数数字序列,求最大 K 个数。
输入项:一个无序的数字序列,和一个数字 K
输出项:K 个数字,代表最大的 K 个数字是什么
逻辑:将无序数列插入到二叉排序数中,采用中序遍历的方式输出前 K 个数字。
Heap sort 堆排序
堆排序在 top K 问题中使用比较频繁。堆排序是采用二叉堆的数据结构来实现的,虽然实质上还是一维数组。二叉堆是一个近似完全二叉树 。
内部不稳定排序,时间复杂度 $O(nlogn)$,空间复杂度 $O(1)$
步骤:
- 构造最大堆(Build_Max_Heap):若数组下标范围为0~n,考虑到单独一个元素是最大堆,则从下标n/2开始的元素均为最大堆。于是只要从n/2-1开始,向前依次构造最大堆,这样就能保证,构造到某个节点时,它的左右子树都已经是最大堆。
- 堆排序(HeapSort):由于堆是用数组模拟的。得到一个最大堆后,数组内部并不是有序的。因此需要将堆化数组有序化。思想是移除根节点,并做最大堆调整的递归运算。第一次将heap[0]与heap[n-1]交换,再对heap[0…n-2]做最大堆调整。第二次将heap[0]与heap[n-2]交换,再对heap[0…n-3]做最大堆调整。重复该操作直至heap[0]和heap[1]交换。由于每次都是将最大的数并入到后面的有序区间,故操作完后整个数组就是有序的了。
- 最大堆调整(Max_Heapify):该方法是提供给上述两个过程调用的。目的是将堆的末端子节点作调整,使得子节点永远小于父节点。
|
|
Bucket Sort 桶排序
桶排序和归并排序非常类似,也使用了归并的思想。大致步骤如下:
外部排序,稳定性取决于桶内排序算法。时间复杂度与分桶数量 K 有关
步骤:
- 设置一个定量的数组当作空桶。桶排序的特点就是数据要有范围(桶不能无限多)。
- Divide - 从待排序数组中取出元素,将元素按照一定的规则塞进对应的桶子去。
- 对每个非空桶进行排序,通常可在塞元素入桶时进行插入排序。
- Conquer - 从非空桶把元素再放回原来的数组中。”
假设输入数据服从均匀分布,然后将输入数据均匀地分配到有限数量的桶中,然后对每个桶再分别排序,对每个桶再使用插入排序算法,最后将每个桶中的数据有序的组合起来。前面了解到基数排序假设输入数据属于一个小区间内的整数,而桶排序则是假设输入是由一个随机过程生成,该过程将元素均匀的分布在一个区间[a,b]上。由于桶排序和计数排序一样均对输入的数据进行了某些假设限制,因此比一般的基于比较的排序算法复杂度低。
|
|
Counting Sort 计数排序
桶的个数=待排序个数,就是计数排序,是桶排序的特例。
计数排序,顾名思义,就是对待排序数组按元素进行计数。使用前提是需要先知道待排序数组的元素范围,将这些一定范围的元素置于新数组中,新数组的大小为待排序数组中最大元素与最小元素的差值。
本质是空间换时间,空间复杂度 O(max-min)。本质是哈希过程
前提:数据是 int 值
步骤:
- 定新数组大小——找出待排序的数组中最大和最小的元素
- 统计次数——统计数组中每个值为i的元素出现的次数,存入新数组C的第i项
- 对统计次数逐个累加——对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
- 反向填充目标数组——将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
- 其中反向填充主要是为了避免重复元素落入新数组的同一索引处。
参考链接:
https://www.bittiger.io/blog/post/4Q4iNNbRYXkWkrAM3#quicksort